Skip to content

fix(digstore-chain): pinned-root verification without full lineage walk (#1431) - #31

Merged
MichaelTaylor3d merged 2 commits into
mainfrom
fix/pinned-root-verify-no-walk-1431
Jul 21, 2026
Merged

fix(digstore-chain): pinned-root verification without full lineage walk (#1431)#31
MichaelTaylor3d merged 2 commits into
mainfrom
fix/pinned-root-verify-no-walk-1431

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds digstore_chain::singleton::verify_pinned_root(chain, store_id, pinned_root) -> Result<()> — a fail-closed, bounded chain-anchor check that verifies a pinned root is the store's CURRENT on-chain generation WITHOUT walking the full singleton lineage.

Root cause (#747): the local /s/<store>:<root> read re-derives the current root via sync_datastore's full forward walk (launcher -> eve -> ... -> tip), which aborts the moment ONE intermediate generation's spend is unparseable ("parse next store: missing child"), so even a valid pinned root becomes unreadable. This unblocks the MVP loopback read tier (#836/#852). The #747 walk bug itself is left to its own fix — this is the additive without-walk path that sidesteps it.

How it works

Every datastore generation is created hinted to its launcher_id (first DataStore::get_recreation_memos memo, == store_id). So the current unspent singleton is located with ONE unspent_coins_by_hint(store_id) query and its on-chain root read from the single spend that created the tip (the tip's parent) — no per-generation walk. pinned_root is accepted only when it equals that current root.

Fail-closed / anti-rollback (§5.3 / NC-9)

Returns Err — never a false Ok — on any chain-read failure, absent unspent singleton, or root mismatch. Only the current on-chain root passes; stale (superseded) and never-on-chain roots are rejected. Historical-but-real generations are intentionally out of scope (would require the enumeration this API avoids).

Tests (§2.2 regression-first)

5 tests build a REAL 3-generation store on the Chia simulator:

  • pinned_current_root_verifies — current root chain-anchors.
  • pinned_root_verifies_when_full_walk_is_brokenthe #747 regression: with an intermediate spend removed, sync_datastore aborts ("singleton spend not found") yet verify_pinned_root still succeeds.
  • stale_root_is_rejected, never_seen_root_is_rejected, unreachable_chain_fails_closed — fail-closed paths.

Blast radius

Additive new public fn on digstore_chain::singleton; from_spend/sync_datastore/current_root semantics unchanged. Consumer: dig-node-core (git-rev dep). SPEC.md §9.1 added. Workspace version 0.18.0 -> 0.19.0 (minor: new public API).

Closes #1431 (super-repo tracking issue).

🤖 loop lane #1431

MichaelTaylor3d and others added 2 commits July 21, 2026 13:17
Push-early WIP checkpoint (§1.8). Adds verify_pinned_root API next.

Co-Authored-By: Claude <noreply@anthropic.com>
…ge walk (#1431)

The local /s/<store>:<root> read (§5.3 loopback tier) hard-fails because the
full singleton-lineage walk (sync_datastore) aborts when any single intermediate
generation's spend is unparseable (#747: "parse next store: missing child"),
so even a valid pinned root becomes unreadable.

Add digstore_chain::singleton::verify_pinned_root(chain, store_id, pinned_root):
a bounded, fail-closed chain-anchor check that locates the current unspent
singleton directly via its launcher-id hint (every generation is hinted to
launcher_id == store_id per DataStore::get_recreation_memos) and reads the
on-chain root from the ONE spend that created the tip — no per-generation walk.
Returns Ok iff pinned_root equals the current on-chain root; Err (never a false
Ok) on any chain-read failure, absent singleton, or root mismatch. This is the
strongest anti-rollback stance (only the current root passes; stale + fabricated
roots rejected), satisfying §5.3 / NC-9.

Additive: existing from_spend / sync_datastore / current_root semantics unchanged
(the #747 walk bug itself is left to its own fix). Regression suite builds a real
3-generation store on the simulator and proves verify_pinned_root chain-anchors
the current root even when the full walk is broken by a missing intermediate spend.

SPEC.md §9.1 documents the contract. Version 0.18.0 -> 0.19.0 (minor: new public API).

Closes #1431

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review July 21, 2026 20:38

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness review (loop-reviewer) — PASS

Fresh independent correctness pass on verify_pinned_root (#1431). Security + adversarial legs run in parallel; this leg is correctness + test validity.

Findings:

  1. Correctness — OK. Locates the current unspent singleton via one unspent_coins_by_hint(store_id) query, reads the tip's root from the tip's PARENT spend via from_spend, and compares to pinned_root. Ok(()) only on an exact match against the live current root; every other path returns Err. Parent→child linkage guarantees from_spend(parent_spend) reconstructs precisely the candidate coin, so no extra coin-id cross-check is needed.
  2. Anti-rollback — OK. Verified the primitive: both the real coinset impl (get_coin_records_by_hint(.., Some(false)) + a !cr.spent guard) and the MockChain return ONLY unspent coins. A singleton has exactly one unspent tip, so stale/spent generations can never be a candidate — a stale root always mismatches. stale_root_is_rejected exercises this.
  3. from_spend(.., &[]) — verified safe (not a bug). sync_datastore passes the parent's delegated_puzzles; this fn passes &[]. Confirmed against chia-sdk-driver 0.18.0 DataStore::from_spend: new_metadata (root_hash) and launcher_id are computed independently of parent_delegated_puzzles; that arg only populates the returned delegated_puzzles field, which this fn never reads. So &[] can neither yield a wrong root nor cause a false parse failure.
  4. Tests — genuine, not vacuous. pinned_root_verifies_when_full_walk_is_broken removes the eve->gen1 spend, asserts sync_datastore really aborts ("singleton spend not found"), and that verify_pinned_root still succeeds (it only needs the tip's parent = gen1 spend, which remains). stale_/never_seen_/unreachable_ genuinely hit the mismatch / no-candidate branches.
  5. Additive/§5.1 — OK. New public fn only; from_spend/sync_datastore/current_root untouched; 0.18.0->0.19.0 minor correct. Reuse (#1343): composed existing primitives, no third lineage impl. Excellent rustdoc; SPEC §9.1 added.
  6. Checks — all green at d6823f2 (build&test ubuntu+windows, version-increment, CodeQL rust/js/actions, commitlint, dig-client-wasm, supply-chain audit).

Verdict: PASS. One non-gating coverage suggestion inline (resolved).

Comment thread crates/digstore-chain/src/singleton.rs
@MichaelTaylor3d
MichaelTaylor3d merged commit d5e52fb into main Jul 21, 2026
11 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the fix/pinned-root-verify-no-walk-1431 branch July 21, 2026 21:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant